aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/websites/[websiteId]/sessions
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/sessions')
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx94
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx32
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx85
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx41
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx84
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx97
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx15
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx43
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx58
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/page.tsx12
12 files changed, 622 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
new file mode 100644
index 0000000..cbb2810
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
@@ -0,0 +1,94 @@
+import {
+ Button,
+ Column,
+ Dialog,
+ DialogTrigger,
+ Heading,
+ Icon,
+ Popover,
+ Row,
+ StatusLight,
+ Text,
+} from '@umami/react-zen';
+import { isSameDay } from 'date-fns';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks';
+import { Eye, FileText } from '@/components/icons';
+import { EventData } from '@/components/metrics/EventData';
+import { Lightning } from '@/components/svg';
+
+export function SessionActivity({
+ websiteId,
+ sessionId,
+ startDate,
+ endDate,
+}: {
+ websiteId: string;
+ sessionId: string;
+ startDate: Date;
+ endDate: Date;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { formatTimezoneDate } = useTimezone();
+ const { data, isLoading, error } = useSessionActivityQuery(
+ websiteId,
+ sessionId,
+ startDate,
+ endDate,
+ );
+ const { isMobile } = useMobile();
+ let lastDay = null;
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Column gap>
+ {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => {
+ const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
+ lastDay = createdAt;
+
+ return (
+ <Column key={eventId} gap>
+ {showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
+ <Row alignItems="center" gap="6" height="40px">
+ <StatusLight color={`#${visitId?.substring(0, 6)}`}>
+ <Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text>
+ </StatusLight>
+ <Row alignItems="center" gap="2">
+ <Icon>{eventName ? <Lightning /> : <Eye />}</Icon>
+ <Text wrap="nowrap">
+ {eventName
+ ? formatMessage(labels.triggeredEvent)
+ : formatMessage(labels.viewedPage)}
+ </Text>
+ <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
+ {eventName || urlPath}
+ </Text>
+ {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
+ </Row>
+ </Row>
+ </Column>
+ );
+ })}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const PropertiesButton = props => {
+ return (
+ <DialogTrigger>
+ <Button variant="quiet">
+ <Row alignItems="center" gap>
+ <Icon>
+ <FileText />
+ </Icon>
+ </Row>
+ </Button>
+ <Popover placement="right">
+ <Dialog>
+ <EventData {...props} />
+ </Dialog>
+ </Popover>
+ </DialogTrigger>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx
new file mode 100644
index 0000000..7c82c17
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx
@@ -0,0 +1,32 @@
+import { Box, Column, Label, Row, Text } from '@umami/react-zen';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useSessionDataQuery } from '@/components/hooks';
+import { DATA_TYPES } from '@/lib/constants';
+
+export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) {
+ const { data, isLoading, error } = useSessionDataQuery(websiteId, sessionId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {!data?.length && <Empty />}
+ <Column gap="6">
+ {data?.map(({ dataKey, dataType, stringValue }) => {
+ return (
+ <Column key={dataKey}>
+ <Label>{dataKey}</Label>
+ <Row alignItems="center" gap>
+ <Text>{stringValue}</Text>
+ <Box paddingY="1" paddingX="2" border borderRadius borderColor="5">
+ <Text color="muted" size="1">
+ {DATA_TYPES[dataType]}
+ </Text>
+ </Box>
+ </Row>
+ </Column>
+ );
+ })}
+ </Column>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx
new file mode 100644
index 0000000..f15e6ee
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx
@@ -0,0 +1,85 @@
+import { Column, Grid, Icon, Label, Row } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks';
+import { Calendar, KeyRound, Landmark, MapPin } from '@/components/icons';
+
+export function SessionInfo({ data }) {
+ const { locale } = useLocale();
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { getRegionName } = useRegionNames(locale);
+
+ return (
+ <Grid columns="repeat(auto-fit, minmax(200px, 1fr)" gap>
+ <Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}>
+ {data?.distinctId}
+ </Info>
+
+ <Info label={formatMessage(labels.lastSeen)} icon={<Calendar />}>
+ <DateDistance date={new Date(data.lastAt)} />
+ </Info>
+
+ <Info label={formatMessage(labels.firstSeen)} icon={<Calendar />}>
+ <DateDistance date={new Date(data.firstAt)} />
+ </Info>
+
+ <Info
+ label={formatMessage(labels.country)}
+ icon={<TypeIcon type="country" value={data?.country} />}
+ >
+ {formatValue(data?.country, 'country')}
+ </Info>
+
+ <Info label={formatMessage(labels.region)} icon={<MapPin />}>
+ {getRegionName(data?.region)}
+ </Info>
+
+ <Info label={formatMessage(labels.city)} icon={<Landmark />}>
+ {data?.city}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.browser)}
+ icon={<TypeIcon type="browser" value={data?.browser} />}
+ >
+ {formatValue(data?.browser, 'browser')}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.os)}
+ icon={<TypeIcon type="os" value={data?.os?.toLowerCase()?.replaceAll(/\W/g, '-')} />}
+ >
+ {formatValue(data?.os, 'os')}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.device)}
+ icon={<TypeIcon type="device" value={data?.device} />}
+ >
+ {formatValue(data?.device, 'device')}
+ </Info>
+ </Grid>
+ );
+}
+
+const Info = ({
+ label,
+ icon,
+ children,
+}: {
+ label: string;
+ icon?: ReactNode;
+ children: ReactNode;
+}) => {
+ return (
+ <Column>
+ <Label>{label}</Label>
+ <Row alignItems="center" gap>
+ {icon && <Icon>{icon}</Icon>}
+ {children || '—'}
+ </Row>
+ </Column>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx
new file mode 100644
index 0000000..d658038
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx
@@ -0,0 +1,41 @@
+import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
+import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile';
+import { useNavigation } from '@/components/hooks';
+
+export interface SessionModalProps extends ModalProps {
+ websiteId: string;
+}
+
+export function SessionModal({ websiteId, ...props }: SessionModalProps) {
+ const {
+ router,
+ query: { session },
+ updateParams,
+ } = useNavigation();
+ const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ router.push(updateParams({ session: undefined }));
+ }
+ };
+
+ return (
+ <Modal
+ placement="bottom"
+ offset="80px"
+ isOpen={!!session}
+ onOpenChange={handleOpenChange}
+ isDismissable
+ {...props}
+ >
+ <Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}>
+ <Dialog variant="sheet">
+ {({ close }) => (
+ <Column padding="6">
+ <SessionProfile websiteId={websiteId} sessionId={session} onClose={() => close()} />
+ </Column>
+ )}
+ </Dialog>
+ </Column>
+ </Modal>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
new file mode 100644
index 0000000..6624d43
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
@@ -0,0 +1,84 @@
+import {
+ Button,
+ Column,
+ Icon,
+ Row,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+ TextField,
+} from '@umami/react-zen';
+import { X } from 'lucide-react';
+import { Avatar } from '@/components/common/Avatar';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useWebsiteSessionQuery } from '@/components/hooks';
+import { SessionActivity } from './SessionActivity';
+import { SessionData } from './SessionData';
+import { SessionInfo } from './SessionInfo';
+import { SessionStats } from './SessionStats';
+
+export function SessionProfile({
+ websiteId,
+ sessionId,
+ onClose,
+}: {
+ websiteId: string;
+ sessionId: string;
+ onClose?: () => void;
+}) {
+ const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <LoadingPanel
+ data={data}
+ isLoading={isLoading}
+ error={error}
+ loadingIcon="spinner"
+ loadingPlacement="absolute"
+ >
+ {data && (
+ <Column gap>
+ {onClose && (
+ <Row justifyContent="flex-end">
+ <Button onPress={onClose} variant="quiet">
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ </Row>
+ )}
+ <Column gap="6">
+ <Row justifyContent="center" alignItems="center" gap="6">
+ <Avatar seed={data?.id} size={128} />
+ <Column width="360px">
+ <TextField label="ID" value={data?.id} allowCopy />
+ </Column>
+ </Row>
+ <SessionStats data={data} />
+ <SessionInfo data={data} />
+
+ <Tabs>
+ <TabList>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <SessionActivity
+ websiteId={websiteId}
+ sessionId={sessionId}
+ startDate={data?.firstAt}
+ endDate={data?.lastAt}
+ />
+ </TabPanel>
+ <TabPanel id="properties">
+ <SessionData sessionId={sessionId} websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Column>
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
new file mode 100644
index 0000000..1693d05
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
@@ -0,0 +1,97 @@
+import { Column, Grid, ListItem, Select } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useMessages,
+ useSessionDataPropertiesQuery,
+ useSessionDataValuesQuery,
+} from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS } from '@/lib/constants';
+
+export function SessionProperties({ websiteId }: { websiteId: string }) {
+ const [propertyName, setPropertyName] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useSessionDataPropertiesQuery(websiteId);
+
+ const properties: string[] = data?.map(e => e.propertyName);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={data}
+ error={error}
+ minHeight="300px"
+ >
+ <Column gap="6">
+ {data && (
+ <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
+ <Select
+ label={formatMessage(labels.event)}
+ value={propertyName}
+ onChange={setPropertyName}
+ placeholder=""
+ >
+ {properties?.map(p => (
+ <ListItem key={p} id={p}>
+ {p}
+ </ListItem>
+ ))}
+ </Select>
+ </Grid>
+ )}
+ {propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const SessionValues = ({ websiteId, propertyName }) => {
+ const { data, isLoading, isFetching, error } = useSessionDataValuesQuery(websiteId, propertyName);
+
+ const propertySum = useMemo(() => {
+ return data?.reduce((sum, { total }) => sum + total, 0) ?? 0;
+ }, [data]);
+
+ const chartData = useMemo(() => {
+ if (!propertyName || !data) return null;
+ return {
+ labels: data.map(({ value }) => value),
+ datasets: [
+ {
+ data: data.map(({ total }) => total),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ }, [propertyName, data]);
+
+ const tableData = useMemo(() => {
+ if (!propertyName || !data || propertySum === 0) return [];
+ return data.map(({ value, total }) => ({
+ label: value,
+ count: total,
+ percent: 100 * (total / propertySum),
+ }));
+ }, [propertyName, data, propertySum]);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={data}
+ error={error}
+ minHeight="300px"
+ >
+ {data && (
+ <Grid columns="1fr 1fr" gap>
+ <ListTable title={propertyName} data={tableData} />
+ <PieChart type="doughnut" chartData={chartData} />
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx
new file mode 100644
index 0000000..e25be9a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatShortTime } from '@/lib/format';
+
+export function SessionStats({ data }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <MetricsBar>
+ <MetricCard label={formatMessage(labels.visits)} value={data?.visits} />
+ <MetricCard label={formatMessage(labels.views)} value={data?.views} />
+ <MetricCard label={formatMessage(labels.events)} value={data?.events} />
+ <MetricCard
+ label={formatMessage(labels.visitDuration)}
+ value={data?.totaltime / data?.visits}
+ formatValue={n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
+ />
+ </MetricsBar>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
new file mode 100644
index 0000000..b1b9f65
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
@@ -0,0 +1,15 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsiteSessionsQuery } from '@/components/hooks';
+import { SessionsTable } from './SessionsTable';
+
+export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) {
+ const queryResult = useWebsiteSessionsQuery(websiteId);
+
+ return (
+ <DataGrid query={queryResult} allowPaging allowSearch>
+ {({ data }) => {
+ return <SessionsTable data={data} />;
+ }}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
new file mode 100644
index 0000000..c8317a2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
@@ -0,0 +1,40 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages } from '@/components/hooks';
+import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function SessionsMetricsBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <MetricsBar>
+ <MetricCard
+ value={data?.visitors?.value}
+ label={formatMessage(labels.visitors)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.visits?.value}
+ label={formatMessage(labels.visits)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.pageviews?.value}
+ label={formatMessage(labels.views)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.countries?.value}
+ label={formatMessage(labels.countries)}
+ formatValue={formatLongNumber}
+ />
+ </MetricsBar>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
new file mode 100644
index 0000000..8e9d2f2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
@@ -0,0 +1,43 @@
+'use client';
+import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { getItem, setItem } from '@/lib/storage';
+import { SessionProperties } from './SessionProperties';
+import { SessionsDataTable } from './SessionsDataTable';
+
+const KEY_NAME = 'umami.sessions.tab';
+
+export function SessionsPage({ websiteId }) {
+ const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity');
+ const { formatMessage, labels } = useMessages();
+
+ const handleSelect = (value: Key) => {
+ setItem(KEY_NAME, value);
+ setTab(value);
+ };
+
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} />
+ <Panel>
+ <Tabs selectedKey={tab} onSelectionChange={handleSelect}>
+ <TabList>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <SessionsDataTable websiteId={websiteId} />
+ </TabPanel>
+ <TabPanel id="properties">
+ <SessionProperties websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <SessionModal websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
new file mode 100644
index 0000000..5d3bb37
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
@@ -0,0 +1,58 @@
+import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
+import Link from 'next/link';
+import { Avatar } from '@/components/common/Avatar';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useMessages, useNavigation } from '@/components/hooks';
+
+export function SessionsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { updateParams } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="id" label={formatMessage(labels.session)} width="100px">
+ {(row: any) => (
+ <Link href={updateParams({ session: row.id })}>
+ <Avatar seed={row.id} size={32} />
+ </Link>
+ )}
+ </DataColumn>
+ <DataColumn id="visits" label={formatMessage(labels.visits)} width="80px" />
+ <DataColumn id="views" label={formatMessage(labels.views)} width="80px" />
+ <DataColumn id="country" label={formatMessage(labels.country)}>
+ {(row: any) => (
+ <TypeIcon type="country" value={row.country}>
+ {formatValue(row.country, 'country')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="city" label={formatMessage(labels.city)} />
+ <DataColumn id="browser" label={formatMessage(labels.browser)}>
+ {(row: any) => (
+ <TypeIcon type="browser" value={row.browser}>
+ {formatValue(row.browser, 'browser')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="os" label={formatMessage(labels.os)}>
+ {(row: any) => (
+ <TypeIcon type="os" value={row.os}>
+ {formatValue(row.os, 'os')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="device" label={formatMessage(labels.device)}>
+ {(row: any) => (
+ <TypeIcon type="device" value={row.device}>
+ {formatValue(row.device, 'device')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="lastAt" label={formatMessage(labels.lastSeen)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/page.tsx b/src/app/(main)/websites/[websiteId]/sessions/page.tsx
new file mode 100644
index 0000000..221ab71
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SessionsPage } from './SessionsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SessionsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Sessions',
+};